'use strict';


const fs = require('fs');
const path = require('path');
const Logger = require('nami-logger');
const filters = require('../lib/filters.js');
const _ = require('nami-utils/lodash-extra.js');
const nfile = require('nami-utils/file');
const JsonParser = require('../lib/json-parser.js');
const getComponent = require('../lib/components').getComponent;
const semver = require('semver');

/**
 * Handles the "database" of installed packages. The registry handles the serialization and de-serialization of
 * objects into the database on behalf of the Nami Manager
 * @class
 * @_private
 */
class Registry {
  /**
   * Constructs an instance of a Nami Registry
   * @param {object} [options]
   * @param {object} [options.logger=null] - External logger to use. If none is provided, a default one will be used
   * @param {string} [options.logLevel=error] - When no external logger is provided, initial log level to use in the
   * autogenerated logger
   * @param {string} [options.prefix=~/.nami] - Registry database location. This path will be used to store
   * information about installed packages.
   * @param {string} [options.encryptionPassword] - If an encryption password is provided, sensible information will be
   * encrypted when using 'secure' serialization policy
   * @param {string} [options.serializationPolicy=secure] - Configures how packages will serialize sensible information
   * such as passwords. 'secure' will make them either
   * to not be serialized or to be serialized encrypted, depending on the 'encryptionPassword' property. 'plain' will
   * just write them in plain text, without any protection
   * @param {object} [options.modulesManager={}] - Modules manager used to resolve package dependencies at runtime
   * @constructs Registry
   */
  constructor(options) {
    options = _.opts(options, {
      prefix: '~/.nami',
      logger: null, logLevel: 'error',
      encryptionPassword: null, serializationPolicy: 'secure',
      modulesManager: {}
    });
    this.prefix = nfile.normalize((options.prefix || '~/.nami'));

    this.logger = options.logger || new Logger({level: options.logLevel, silenced: true});

    this._modulesManager = options.modulesManager || {};
    // How packages will treat sensible information such as passwords (secure, standard)
    this.serializationPolicy = options.serializationPolicy;

    // If an encryption password is provided, sensible information will be encrypted in 'secure' serialization mode
    this._setupEncryption(options.encryptionPassword);

    this._loadedData = {components: {}};
    this._objects = {};
    this.load();
  }

  _setupEncryption(password) {
    // The crypter will be passed on to any loaded package later on
    this._crypter = password ? new filters.CryptFilter({password: password}) : null;
  }

  /**
   * File used to store information about installed packages
   * @type {string}
   */
  get databaseFile() {
    return path.join(this.prefix, 'registry.json');
  }

  /**
   * Directory where installed packages metadata is stored
   * @type {string}
   */
  get componentsDir() {
    return path.join(this.prefix, 'components');
  }

  /**
   * Creates a component object from a serialized package
   * @param {object} pkgData - Package definition data
   * @param {object} pkgData.jsonFile=null - Metadata JSON file defining the package
   * @param {object} [pkgData.jsFiles=[]] - List of JS files defining the component logic
   * @param {object} [initOptions] - Options used to initialize the component after deserialization.
   * TODO: Properly define this
   * after refactoring Component
   * @param {object} options - Options
   */
  deserializePackage(pkgData, initOptions, options) {
    options = _.opts(options, {softSchemaValidation: false, logger: this.logger});
    pkgData = _.defaults(pkgData || {}, {manifest: null, jsonFile: null, jsFiles: null});
    const serializedData = pkgData.data || {environment: {}};
    const environment = serializedData.environment || {};
    const metaFile = pkgData.jsonFile;
    let obj = null;
    if (nfile.exists(metaFile)) {
      const metadataDir = options.metadataDir || serializedData.rootDir || this.srcDir || path.dirname(metaFile);
      const json = JsonParser.parse(metaFile);
      obj = getComponent(json, _.opts(initOptions, {
        crypter: this._crypter,
        modulesManager: this._modulesManager,
        serializationPolicy: this.serializationPolicy,
        srcDir: null,
        metadataDir: metadataDir,
        manifest: pkgData.manifest,
        installedFiles: pkgData.installedFiles,
        logger: options.logger,
        jsFiles: pkgData.jsFiles,
        installdir: null,
        installPrefix: null,
        environment: environment
      }), options);
    } else {
      throw new Error(`Cannot find metadata file '${metaFile}'`);
    }
    // Deserialize data from a previous installation
    obj.deserialize(pkgData);
    obj.initialize();
    return obj;
  }

  /**
   * Load a package from the registry by id
   * @param {string} id - Component unique id
   * @param {object} [options] - Options used to initialize the component after deserialization.
   * TODO: Properly define this
   * after refactoring Component
   * @param {boolean} [options.softSchemaValidation=false] - If enabled, do not throw an error if the component JSON
   * file does not validate. If will be
   * reported as a warning instead
   * @param {boolean} [options.reload=false] - By default, packages loaded from the registry are cached. Enabling
   * reload forces re-creating them
   * @returns {object} - The loaded object from the registry
   */
  loadPackage(id, options) {
    options = _.opts(options, {softSchemaValidation: false, reload: false});
    if (_.has(this._objects, id) && !options.reload) {
      return this._objects[id];
    }

    const data = this.getPkgData(id);
    if (data === null) return null;
    const definition = data.definition;
    const rootDir = path.join(this.componentsDir, definition.root);
    const obj = this.loadPackageFromDir(rootDir, {
      installdir: data.installdir, installPrefix: data.installPrefix, metadataDir: rootDir
    }, {
      jsonFile: definition.resources.json,
      jsFiles: definition.resources.js,
      installedFiles: definition.resources.installedFiles,
      data: data,
      softSchemaValidation: options.softSchemaValidation,
      reload: options.reload
    });
    this._objects[id] = obj;
    return obj;
  }
  _getPackageIds() {
    return _.keys(this._loadedData.components);
  }
  loadAllPackages(options) {
    options = _.opts(options, {softSchemaValidation: false, abortOnError: true});
    const result = {};
    _.each(this._getPackageIds(), id => {
      try {
        result[id] = this.loadPackage(id, _.pick(options, 'softSchemaValidation'));
      } catch (e) {
        if (options.abortOnError) {
          throw e;
        } else {
          this.logger.warn(`Error loading package '${id}': ${e.message}`);
        }
      }
    });
    return result;
  }
  /**
   * Creates a component object from a directory containing a serialized package
   * @param {object} dir - Directory containing the serialized package
   * @param {object} [initOptions] - Options used to initialize the component after deserialization.
   * TODO: Properly define this
   * after refactoring Component
   * @param {object} options - Options
   * @param {object} [options.jsonFile=nami.json] - Alternative path to the JSON file relative to the directory
   * @param {object} [options.jsFiles=[]] - JS files. If empty, all recognized files will be loaded
   */
  loadPackageFromDir(dir, initOptions, options) {
    options = _.opts(options, {
      manifest: 'manifest.txt',
      jsonFile: null,
      jsFiles: null,
      data: null,
      installedFiles: null
    });

    if (options.jsonFile === null) {
      _.each(['nami.json', 'bitnami.json'], f => {
        if (nfile.exists(path.join(dir, f))) {
          options.jsonFile = f;
          return false;
        }
      });
      if (options.jsonFile === null) {
        throw new Error(`Cannot find required file nami.json under ${dir}`);
      }
    }

    const pkgData = {};
    _.each(['jsonFile', 'manifest', 'installedFiles'], function(key) {
      if (_.isEmpty(options[key])) return;

      const f = path.join(dir, options[key]);
      if (nfile.exists(f)) pkgData[key] = f;
    });
    if (!_.isEmpty(options.jsFiles)) {
      pkgData.jsFiles = _.map(options.jsFiles, f => path.join(dir, f));
    }
    if (_.isReallyObject(options.data)) pkgData.data = options.data;
    initOptions = _.opts(initOptions, {srcDirRoot: dir});
    return this.deserializePackage(pkgData, initOptions, _.pick(options, ['logger', 'softSchemaValidation']));
  }

  /**
   * Save in memory database into a file
   * @param {object} [options]
   * @param {string} [options.file=databaseFile] - File used to serialize the data. Defaults to the databaseFile
   */
  save(options) {
    options = _.opts(options, {file: null});
    const file = options.file || this.databaseFile;
    nfile.write(file, JSON.stringify(this._loadedData || {}, null, 4));
    return file;
  }

  /**
   * Load registry file into memory
   * @param {object} [options]
   * @param {string} [options.file=databaseFile] - File from where to load the data. Defaults to the databaseFile
   * @param {string[]} [options.components=[]] - If not blank and reset is disabled, only reload information for
   * the given components
   * @param {boolean} [options.reset=true] - If enabled, clean up existing data before loading
   */
  load(options) {
    options = _.opts(options, {file: null, components: [], reset: true});
    const file = options.file || this.databaseFile;
    if (nfile.exists(file)) {
      // https://www.npmjs.com/package/json-stable-stringify
      const data = JSON.parse(fs.readFileSync(file, {encoding: 'utf8'}));
      if (!_.isReallyObject(data)) throw new Error(`Invalid registry file data`);
      if (options.reset || _.isEmpty(options.components)) {
        this._loadedData = data;
        if (!_.isReallyObject(data.components)) data.components = {};
        this._objects = {};
      } else {
        _.extend(this._loadedData.components, _.pick(data.components || {}, options.components));
        this._objects = _.omit(this._objects, options.components);
      }
    }
  }

  /**
   * Reload data for the given components. This is a shorthand method to load({components: components, reset: false})
   * @param {string[]} [components=[]] - If not blank, only reload information for the given components
   */
  reload(components) {
    this.load({components: components, reset: false});
  }

  updateResources(metaObject, newObject, options) {
    options = _.opts(options, {initialize: false});
    const componentId = this.getObjectId(metaObject);
    this.logger.debug(`Refreshing ${componentId}`);
    this.update(metaObject, {initialize: true});
  }

  /**
   * Get component object unique ID
   * @param {object} object
   * @returns {string} - The unique id for the component object
   */
  getObjectId(obj) {
    return obj.id;
  }

  /**
   * Get stored package data for the given ID
   * @param {string} id - ID of the package to look for
   * @returns {object} - Package data
   */
  getPkgData(id) {
    return this._loadedData.components[id] || null;
  }
  _setComponentData(metaObject, data, options) {
    options = _.opts(options, {initialize: false, operation: 'set', update: 'false'});

    const id = this.getObjectId(metaObject);

    const components = this._loadedData.components;

    let currentData = this.getPkgData(id);

    if (currentData === null || options.initialize) {
      if (!metaObject.srcDirRoot) throw new Error(`A component cannot be initialized without a srcDirRoot`);
      const cmponentDir = path.join(this.componentsDir, metaObject.id);
      currentData = metaObject.serialize(cmponentDir);
      currentData.definition.root = metaObject.id;
    } else if (options.update) {
      currentData = _.extend(currentData, metaObject.serializeData());
    }
    _.each(data, function(value, key) {
      if (_.isReallyObject(value) && options.operation === 'add') {
        _.extend(currentData[key], value);
      } else {
        currentData[key] = value;
      }
    });
    components[id] = currentData;
    this._objects[id] = metaObject;
    this.save();
  }

  /**
   * Updates package metadata from component object
   * @param object
   */
  update(metaObject, options) {
    options = _.opts(options, {initialize: false});
    this._setComponentData(metaObject, {}, _.opts(options, {update: true}, {mode: 'overwrite'}));
  }

  /**
   * Registers an new package into the registry
   * @param object - Component object to register
   */
  register(object) {
    this.update(object, {initialize: true});
  }
  /**
   * Removes an existing package from the registry
   * @param object - Component object to unregister
   * @param {object} [options]
   * @param {boolean} [options.delete=true] - Clean up component metadata resources after unregistering it
   */
  unregister(metaObject, options) {
    options = _.opts(options, {delete: true});

    const id = this.getObjectId(metaObject);
    this.logger.debug(`Unregistering ${id}`);

    const componentData = this.getPkgData(id);

    this._loadedData.components = _.omit(this._loadedData.components, id);
    this._objects = _.omit(this._objects, id);
    this.save();

    if (options.delete && componentData) {
      const componentDir = nfile.sanitize(
        _.tryOnce(componentData, 'definition', 'root'),
        {mustBeRelative: true, noupdir: true}
      );
      if (!_.isEmpty(componentDir)) {
        nfile.delete(path.join(this.componentsDir, componentDir));
      }
    }
  }

  /**
   * Get all registered packages metadata. It does not return deserialized packages (you must call loadPackage for that)
   * but rather a very basic metadata hash.
   * @returns {object} - A hash by component id containing basic component metadata
   */
  getPackages() {
    return this._loadedData.components || {};
  }

  /**
   * Look for a package given a certain semver specification (sample, sample@3.5...)
   * @param {string} searchTerm - Search specification, as a minimum, it should contain the id or name
   * of the component to look for. It can also include contain a semversion specification
   * (foo, sample@2.45, sample@ > 5.5 || < 4 ...).
   * @param {object} [options]
   * @param {string|string[]} [options.searchBy=[id,name]] - Methods used for searching in order. The first one
   * to succeed will skip the rest.
   * @returns {object[]} List of packages metadata
   */
  search(searchTerm, options) {
    options = _.sanitize(options, {searchBy: ['id', 'name']});
    let res = [];
    const _this = this;
    const searchSpec = {reference: null, version: null};

    // Ensure we work with an array, even if a string was provided
    const searchBy = _.uniq(_.toArrayIfNeeded(options.searchBy));

    const match = searchTerm.match(/(.+?)(@["']?(.*?)["']?)?$/);
    if (match) {
      searchSpec.reference = match[1];
      searchSpec.version = match[3] || null;
    } else {
      throw new Error('Invalid search term provided');
    }

    const componentVersionSatisfies = function(component, versionSpec) {
      return versionSpec === null || semver.satisfies(component.version, versionSpec);
    };
    _.each(searchBy, function(method) {
      switch (method) {
        case 'id': {
          const pkgData = _this.getPkgData(searchSpec.reference);
          if (pkgData !== null && componentVersionSatisfies(pkgData, searchSpec.version)) {
            res.push(pkgData);
          }
          break;
        }
        case 'name':
          res = _.filter(_this.getPackages(), function(data) {
            return data.name === searchSpec.reference && componentVersionSatisfies(data, searchSpec.version);
          });
          break;
        default:
          throw new Error(`Unknown search method ${method}`);
      }
      // We already succeeded so we early exit the iteration
      if (!_.isEmpty(res)) return false;
    });
    return res;
  }
}

module.exports = Registry;
